筆記目錄

Skip to content

RCSI 簡介與改良版 Entity Framework 鎖定提示攔截器

前幾天無意間發現 Google 的 NotebookLM 其實早在 2025 年 4 月 30 日的 Podcast 就支援中文語系(好笑的是,直到 2026 年 1 月時,Gemini 還信誓旦旦地騙我說不支援)。於是我一時興起,把手邊的一些技術筆記丟進去,想說轉成 Podcast 通勤時可以聽聽。

因為 Antigravity 認為我的筆記內容充滿了決策過程,建議我將風格設定為「辯論」。畢竟我也不認為自己寫的筆記絕對正確,所以也順便下 Prompt 請它幫我檢查是否有錯誤或過時的內容。

結果產生出來的內容讓我有點傻眼,感覺很多評論是為了批評而批評:

  • 為辯論而辯論:我在筆記中提到習慣使用 DateTimeOffset,當時遇到同事在 Entity Framework 使用 DateTime 搭配 UTC 時遇到的雷點(我找出原因並提供解法)。結果評論結論竟然是:我應該讓同事直接改用 DateTimeOffset,而不是在 DateTime 上花時間糾結?
  • 畫錯重點:這兩位 AI 主持人花了大量篇幅在攻防我的「前言」和「背景介紹」,卻忽略了主文的技術細節。
  • 指鹿為馬:我明明只是做語法示範或探討某些機制的筆記,文中也明確寫了不建議使用或有哪些潛在問題,結果它竟然說我在「推廣」這些做法?

好吧,我承認我是玻璃心,後來把這些 Podcast 全部刪除了。

不過,也不能說完全沒收穫,我從中聽到了一個關鍵詞:RCSI (Read Committed Snapshot Isolation)

還記得 2018 年左右面試某些 高流量或高併發系統 的公司時,懂得在 SQL Server 使用 WITH (NOLOCK) 幾乎是必考題,這代表你知道如何避免查詢被寫入鎖住。但最近查了資料才發現,現在這做法在某些情境下被視為一種 Anti-Pattern(反模式)

因為最近比較忙,也有點懶,所以這篇就簡單筆記一下重點:

RCSI 是什麼?

全名是 Read Committed Snapshot Isolation。簡單來說,它透過「版本控制」的概念來解決鎖定問題。當有人在寫入資料時,讀取的操作不會被擋住,也不會讀到寫到一半的髒資料,而是讀取「寫入前的最後一個版本(Snapshot)」。

RCSI 是全域開關嗎?

是的,RCSI (READ_COMMITTED_SNAPSHOT) 是 資料庫層級 (Database Level) 的設定。

一旦開啟(ALTER DATABASE ... SET READ_COMMITTED_SNAPSHOT ON),它會改變整個資料庫中所有使用 Read Committed 隔離層級(預設值)的行為。

  • 無法針對「部分資料表」開啟。
  • 這是全域生效的,所有未特別指定隔離層級的查詢都會自動變成「讀取快照」模式。

TIP

若要「選擇性」使用快照隔離,需改用 Snapshot Isolation,並在 EF 建立 Transaction 時顯式指定 IsolationLevel.Snapshot。 但這種精細控制對團隊的開發要求相當高。若是交易頻繁的專案,老實說,連我自己都沒把握能花時間去一一釐清並做出正確的層級判斷。

WARNING

請注意,RCSI 主要是解決查詢方面的鎖定問題(避免讀取被寫入阻塞)。 若要處理資料更新時的併發衝突(例如兩人同時修改同一筆資料),仍然必須搭配 RowVersion (樂觀鎖) 機制來確保資料一致性,RCSI 並不能取代 RowVersion。

RCSI vs. NOLOCK vs. READPAST

  • WITH (NOLOCK): 為了不被鎖,我願意讀取「髒資料 (Dirty Read)」。

  • WITH (READPAST): 遇到被鎖住的行就直接「跳過」。適用於 Queue 的處理,但不適合一般報表(因為資料會少)。

  • RCSI: 讀取一致的「快照資料」。不用加 Hint 就能達成非阻塞讀取。

TIP

不論是髒讀還是快照讀,在高併發情境下都不保證拿到「最新狀態」的資料,因為資料本來就處於持續異動中。差別在於:快照讀只會讀到已提交的歷史版本,而髒讀可能讀到尚未完成的中間狀態,等同破壞原子性,因此帶來的風險層級明顯更高。

為什麼以前不用,現在變主流?

RCSI 的代價是它會大量使用 TempDB 來儲存資料版本。在傳統機械硬碟 (HDD) 時代,這會導致嚴重的 I/O 瓶頸。 但現在主流環境變了:

  1. SSD 普及:I/O 速度大幅提升,TempDB 的負擔不再是致命傷。
  2. 雲端預設開啟:例如 Azure SQL Database 預設就是開啟 RCSI 的。

所以,如果硬體撐得住(空間與 I/O),直接開 RCSI 是比到處加 NOLOCK 更乾淨的解法(主要是 Entity Framework 若要處理 NOLOCK,通常得用攔截器,比較麻煩)。

TagWith + Interceptor 改良實作

當然以現在來說 RCSI 應該是最佳解法,但也不是全部的專案都適合,我們還是需要控制鎖定層級。

以前我寫過一篇 如何在 Entity Framework 中增加 WITH (NOLOCK) 和 Parameter Sniffing 的處理 的筆記,當時的解法是用 DbCommandInterceptor 搭配正規式(Regex),把所有的 SELECT 都強制加上 NOLOCK

說實話,我對那個解法一直不滿意。因為在跑清單報表時,資料精確性或許不用那麼高;但當你在取出資料進行異動時,讀到髒資料是很危險的。當時我只能利用「排除 TOP 2」這種旁門左道來處理,但如果是批次資料處理(必須撈清單資料),依然會出問題。

TagWith 的發現

早在 2024 年,有同事在處理 Parameter Sniffing 問題時,就有找到 TagWith 的用法。但我當時誤以為這只是加個靜態註解,然後再用攔截器去字串搜尋做替換。最近才發現,TagWith 是 EF Core 內建的功能,它能將註解完整帶入產生的 SQL 中,這給了我們一個完美的「開關」。

改良後的解法

這範例選擇預設不加 WITH (NOLOCK)OPTION,而是透過 TagWith 進行顯式宣告。只有當我明確說這條 Query 要 NOLOCK 時,攔截器才介入。

1. 定義擴充方法 (Extension Methods)

讓程式碼寫起來像 Fluent API,語意更清晰。

csharp
public static class EfHintExtensions {
    public const string TagNoLock = "SQL_HINT: NOLOCK";
    public const string TagReadPast = "SQL_HINT: READPAST";
    public const string TagOptionUnknown = "SQL_OPTION: OPTIMIZE FOR UNKNOWN";
    public const string TagRecompile = "SQL_OPTION: RECOMPILE";

    public static IQueryable<T> WithNoLock<T>(this IQueryable<T> query) => query.TagWith(TagNoLock);
    public static IQueryable<T> WithReadPast<T>(this IQueryable<T> query) => query.TagWith(TagReadPast);
    public static IQueryable<T> WithOptionUnknown<T>(this IQueryable<T> query) => query.TagWith(TagOptionUnknown);
    public static IQueryable<T> WithRecompile<T>(this IQueryable<T> query) => query.TagWith(TagRecompile);
}

2. 實作攔截器 (Interceptor)

這個版本的攔截器做了兩個改良:

  1. 支援 Schema:改良了正規式,支援 [dbo].[Table] 這種帶有 Schema 的格式(舊版正規式會失效)。
  2. Option 合併:自動處理多個 Option 的合併,避免 SQL 語法錯誤。
csharp
public class SqlTaggingInterceptor : DbCommandInterceptor {
    private static readonly RegexOptions regexOptions = RegexOptions.Multiline | RegexOptions.IgnoreCase;

    // 改良版 Regex:支援 Schema (例如 [dbo].[Table])
    // 理論上資料表名稱不該有空白,但是技術上允許,所以這邊也增加處理
    private static readonly Regex tableAliasRegex = new(
        @"(?<tableAlias>(?:FROM|JOIN)\s+(?:\[[^\]]+\]\.)?\[[^\]]+\]\s+AS\s+\[[^\]]+\])(?!\s+WITH\s*\()",
        regexOptions
    );

    // 攔截 Reader 與 Scalar (Count/Any 也要處理!)
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result
    ) {
        FixCommand(command);
        return base.ReaderExecuting(command, eventData, result);
    }

    // ... (Async 與 Scalar 方法省略,記得都要呼叫 FixCommand)

    private static void FixCommand(DbCommand command) {
        if (string.IsNullOrWhiteSpace(command.CommandText)) {
            return;
        }
        string text = command.CommandText;
        bool isChanged = false;

        // 1. 處理 Table Hints (NOLOCK / READPAST)
        string hintToApply = null;
        if (text.Contains(EfHintExtensions.TagNoLock)) {
            hintToApply = "WITH (NOLOCK)";
        }
        else if (text.Contains(EfHintExtensions.TagReadPast)) {
            hintToApply = "WITH (READPAST)";
        }

        if (hintToApply != null) {
            text = tableAliasRegex.Replace(text, $"${{tableAlias}} {hintToApply}");
            isChanged = true;
        }

        // 2. 處理 Query Options
        List<string> options = new ();
        if (text.Contains(EfHintExtensions.TagOptionUnknown)) {
            options.Add("OPTIMIZE FOR UNKNOWN");
        }
        if (text.Contains(EfHintExtensions.TagRecompile)) {
            options.Add("RECOMPILE");
        }

        if (options.Count > 0) {
            text = text.TrimEnd().TrimEnd(';');
            text += $" OPTION ({string.Join(", ", options)});";
            isChanged = true;
        }

        if (isChanged) {
            command.CommandText = text;
        }
    }
}

實際使用

現在,可以控制每一條查詢的行為,而不必擔心改壞其他的 SQL:

csharp
// 報表:允許髒讀 + 解決參數嗅探
List<Order> orders = context.Orders
    .WithNoLock()
    .WithOptionUnknown()
    .ToList();

// Queue:跳過鎖定行
Job job = context.Jobs
    .WithReadPast()
    .FirstOrDefault();

結論

不論 WITH (NOLOCK) 還是 Option 處理參數嗅探,目前主流都有更適合從資料庫處理的作法,例如開啟 RCSI 或使用 Query Store 進行處理。如果在硬體與維運跟得上(I/O 效能與 TempDB 空間監控),尤其有 DBA 的情況下,建議使用。在多數讀多寫少、以查詢為主的系統中,RCSI 是合理預設。它能從根本上解決讀寫衝突,而不需要在程式碼中到處加 Hint。 而如果是為了解決 Parameter Sniffing,從 SQL Server 2016 開始引入的 Query Store 也能提供更好的執行計畫管理與強制機制。

當然,這一切的前提是硬體資源(TempDB 空間與 I/O)要跟得上,且最好有 DBA 協助評估與監控。但在現代化的雲端環境或 SSD 設備下,這些成本通常是值得投資的。

延伸探討:關於全域攔截與隱形機制的思辨

在討論 RCSI 與 TagWith 的過程中,不可避免地會遇到一個爭議:到底該不該用框架做全域處理?

對於 AI 主持人認為「不該從全域著手」、「不該進行隱形的黑魔法」,我則抱持不同的看法。

不論是 Entity Framework 還是 ASP.NET Core,框架本身就做了很多類似的事情。當有人批評「從攔截器改 SQL 會讓產生的 SQL 與開發者預期不同」時,難道開發者真的覺得自己能完全掌握 EF 產生出來的每一句 SQL 嗎?

事實上,更多人可能只了解當中一部分的機制(包含我在內),甚至有人完全不知道框架背後幫忙處理了什麼(例如 Parameter Sniffing 的影響)。

舉例來說:

  • ASP.NET Core 的大小寫轉換: 早期 ASP.NET Framework 的 JSON 屬性名稱預設與 C# DTO 一致(PascalCase),這讓習慣 camelCase 的前端開發者感到彆扭。因此 ASP.NET Core 改為預設強制轉換為 camelCase。這就是一個典型的 框架全域處理 例子。

  • Model Validation: 過往我們需要在 Action 中手動判斷 ModelState.IsValid,但後來 ASP.NET Core Web API 甚至預設直接攔截驗證失敗的請求並回傳 400,完全不需要開發者寫一行程式碼。

所以,很多情況下從框架進行全域處理反而是正確的選擇。問題不在於「是否全域」,而在於哪種方式更能降低開發團隊的認知負荷 (Cognitive Load)

認知負荷與預設行為的權衡

AsNoTracking() 也是一個很好的例子。它取出來的資料因為少了一些追蹤資訊,效能較佳。理論上我們會建議團隊在「沒有要異動的查詢」一律加上 AsNoTracking()

但在實務上,我們很容易看到兩種混亂的情況:

  1. 該加沒加:導致後續要 Update 時失敗(因為被追蹤了)。
  2. 不該加卻加了:導致無法異動,結果沒搞清楚狀況的人去手動變更 EntityState 硬要讓它可以異動,造成更混亂的程式碼。

與其依賴開發者的「自律」或「記憶力」,不如在架構上做切分:把 Query ServiceRepository 分開。只要是從 Query Service 出來的資料,預設就帶有 AsNoTracking()。這樣團隊在使用 Query Service 時,完全不用思考「要不要加 NoTracking」,因為架構已經幫你做出了最安全的預設決定。

這就是我想強調的重點:看哪個情境更常用、更容易辨別情境、判斷錯誤造成的風險最低。

我們可以從三個維度來評估「要不要做全域處理」:

  1. 哪個情境更常用? 這取決於專案特性。如果是報表系統,可能 90% 的查詢都該加 NOLOCK;如果是交易系統,可能 90% 都不該加。

  2. 哪個更容易辨別情境? 有時候,全域預設開啟反而更好辨別。

    • 預設不加 開發者每次寫 Query 都要判斷「這個查詢可以髒讀嗎?」這意味著必須深刻理解業務邏輯,判斷成本較高。
    • 預設加 開發者只需判斷「這個查詢絕對不能髒讀嗎(例如扣庫存)?」相對來說,這種高風險操作更容易識別,誤判門檻較低。
  3. 判斷錯誤造成的風險最低?

    • 預設不加:通常風險最低(這點大家都同意)。
    • 預設加 + 誤判:資料不一致、交易錯誤(嚴重)。
    • 預設不加 + 誤判:查詢變慢、可能鎖住 (Deadlock)(通常可忍受)。

雖然從第 3 點來看,「預設不加」在大部分情況下風險最低,也是最保守的選擇。但如果結合第 1 點(業務特性)與第 2 點(辨別難度)來看,某些專案或許「預設全域處理」反而能大幅降低團隊的開發負擔與心智成本。

對於要使用「顯式宣告」還是「隱式黑魔法」,重點不在於技術手段本身,而在於它是否能有效降低團隊的認知負荷,並符合專案的風險容忍度。脫離具體情境去談 Anti-Pattern,往往只是另一種形式的教條主義。

異動歷程

  • 2026-02-05 初版文件建立。